diff options
Diffstat (limited to 'packages/console/app/src/routes/workspace/[id]/usage')
5 files changed, 1083 insertions, 0 deletions
diff --git a/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css new file mode 100644 index 000000000..24b85be74 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css @@ -0,0 +1,145 @@ +.root { + [data-component="empty-state"] { + padding: var(--space-20) var(--space-6); + text-align: center; + border: 1px dashed var(--color-border); + border-radius: var(--border-radius-sm); + height: 400px; + display: flex; + align-items: center; + justify-content: center; + + p { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + } + + [data-slot="filter-container"] { + margin-bottom: 0; + display: flex; + align-items: center; + gap: var(--space-3); + + [data-component="dropdown"] { + [data-slot="trigger"] { + border: 1px solid var(--color-border); + background-color: var(--color-bg); + padding: var(--space-2) var(--space-3); + border-radius: var(--border-radius-sm); + color: var(--color-text); + font-size: var(--font-size-sm); + line-height: 1.5; + + &:hover { + border-color: var(--color-accent); + } + + &:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-alpha); + } + } + + [data-slot="chevron"] { + opacity: 0.6; + } + + [data-slot="dropdown"] { + min-width: 200px; + max-height: 300px; + overflow-y: auto; + padding: var(--space-1); + } + } + } + + [data-slot="month-picker"] { + display: flex; + align-items: center; + background-color: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + padding: 0; + } + + [data-slot="month-button"] { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none !important; + color: var(--color-text); + cursor: pointer; + padding: var(--space-2) var(--space-3); + border-radius: var(--border-radius-xs); + transition: background-color 0.2s; + line-height: 1; + + &:hover { + background-color: var(--color-bg-hover); + } + + svg { + display: block; + width: 16px; + height: 16px; + stroke-width: 2; + } + } + + [data-slot="month-label"] { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text); + line-height: 1.5; + min-width: 140px; + text-align: center; + white-space: nowrap; + } + + [data-slot="model-item"] { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + cursor: pointer; + transition: background-color 0.2s; + font-size: var(--font-size-sm); + color: var(--color-text); + border: none !important; + background: none; + width: 100%; + text-align: left; + white-space: nowrap; + + &:hover { + background: var(--color-bg-hover); + } + + span { + flex: 1; + user-select: none; + } + } + + [data-slot="chart-container"] { + padding: var(--space-6); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + height: 400px; + } + + @media (max-width: 40rem) { + [data-slot="chart-container"] { + height: 300px; + padding: var(--space-4); + } + + [data-component="empty-state"] { + height: 300px; + } + } +} diff --git a/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx new file mode 100644 index 000000000..bb4b4f4cf --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx @@ -0,0 +1,515 @@ +import { and, Database, eq, gte, inArray, isNull, lt, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js" +import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js" +import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" +import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" +import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js" +import { useParams } from "@solidjs/router" +import { createEffect, createMemo, onCleanup, Show, For } from "solid-js" +import { createStore } from "solid-js/store" +import { withActor } from "~/context/auth.withActor" +import { Dropdown } from "~/component/dropdown" +import { IconChevronLeft, IconChevronRight } from "~/component/icon" +import styles from "./graph-section.module.css" +import { + Chart, + BarController, + BarElement, + CategoryScale, + LinearScale, + Tooltip, + Legend, + type ChartConfiguration, +} from "chart.js" +import { useI18n } from "~/context/i18n" + +Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend) + +async function getCosts(workspaceID: string, year: number, month: number) { + "use server" + return withActor(async () => { + const startDate = new Date(year, month, 1) + const endDate = new Date(year, month + 1, 1) + const usageData = await Database.use((tx) => + tx + .select({ + date: sql<string>`DATE(${UsageTable.timeCreated})`, + model: UsageTable.model, + totalCost: sum(UsageTable.cost), + keyId: UsageTable.keyID, + plan: sql<string | null>`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`, + }) + .from(UsageTable) + .where( + and( + eq(UsageTable.workspaceID, workspaceID), + gte(UsageTable.timeCreated, startDate), + lt(UsageTable.timeCreated, endDate), + ), + ) + .groupBy( + sql`DATE(${UsageTable.timeCreated})`, + UsageTable.model, + UsageTable.keyID, + sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`, + ) + .then((x) => + x.map((r) => ({ + ...r, + totalCost: r.totalCost ? parseInt(r.totalCost) : 0, + plan: r.plan as "sub" | "lite" | "byok" | null, + })), + ), + ) + + // Get unique key IDs from usage + const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null)) + + // Second query: get all existing keys plus any keys from usage + const keysData = await Database.use((tx) => + tx + .select({ + keyId: KeyTable.id, + keyName: KeyTable.name, + userEmail: AuthTable.subject, + timeDeleted: KeyTable.timeDeleted, + }) + .from(KeyTable) + .innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID))) + .innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email"))) + .where( + and( + eq(KeyTable.workspaceID, workspaceID), + usageKeyIds.size > 0 + ? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted)) + : isNull(KeyTable.timeDeleted), + ), + ) + .orderBy(AuthTable.subject, KeyTable.name), + ) + + return { + usage: usageData, + keys: keysData.map((key) => ({ + id: key.keyId, + displayName: `${key.userEmail} - ${key.keyName}`, + deleted: key.timeDeleted !== null, + })), + } + }, workspaceID) +} + +const MODEL_COLORS: Record<string, string> = { + "claude-sonnet-4-5": "#D4745C", + "claude-sonnet-4": "#E8B4A4", + "claude-opus-4": "#C8A098", + "claude-haiku-4-5": "#F0D8D0", + "claude-3-5-haiku": "#F8E8E0", + "gpt-5.1": "#4A90E2", + "gpt-5.1-codex": "#6BA8F0", + "gpt-5": "#7DB8F8", + "gpt-5-codex": "#9FCAFF", + "gpt-5-nano": "#B8D8FF", + "grok-code": "#8B5CF6", + "big-pickle": "#10B981", + "kimi-k2": "#F59E0B", + "qwen3-coder": "#EC4899", + "glm-4.6": "#14B8A6", +} + +function getModelColor(model: string): string { + if (MODEL_COLORS[model]) return MODEL_COLORS[model] + + const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0) + const hue = Math.abs(hash) % 360 + return `hsl(${hue}, 50%, 65%)` +} + +function formatDateLabel(dateStr: string): string { + const date = new Date() + const [y, m, d] = dateStr.split("-").map(Number) + date.setFullYear(y) + date.setMonth(m - 1) + date.setDate(d) + date.setHours(0, 0, 0, 0) + const month = date.toLocaleDateString(undefined, { month: "short" }) + const day = date.getUTCDate().toString().padStart(2, "0") + return `${month} ${day}` +} + +function addOpacityToColor(color: string, opacity: number): string { + if (color.startsWith("#")) { + const r = parseInt(color.slice(1, 3), 16) + const g = parseInt(color.slice(3, 5), 16) + const b = parseInt(color.slice(5, 7), 16) + return `rgba(${r}, ${g}, ${b}, ${opacity})` + } + if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla") + return color +} + +export function GraphSection() { + let canvasRef: HTMLCanvasElement | undefined + let chartInstance: Chart | undefined + const params = useParams() + const i18n = useI18n() + const now = new Date() + const [store, setStore] = createStore({ + data: null as Awaited<ReturnType<typeof getCosts>> | null, + year: now.getFullYear(), + month: now.getMonth(), + key: null as string | null, + model: null as string | null, + modelDropdownOpen: false, + keyDropdownOpen: false, + colorScheme: "light" as "light" | "dark", + }) + const onPreviousMonth = async () => { + const month = store.month === 0 ? 11 : store.month - 1 + const year = store.month === 0 ? store.year - 1 : store.year + setStore({ month, year }) + } + + const onNextMonth = async () => { + const month = store.month === 11 ? 0 : store.month + 1 + const year = store.month === 11 ? store.year + 1 : store.year + setStore({ month, year }) + } + + const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false }) + + const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false }) + + const getModels = createMemo(() => { + if (!store.data?.usage) return [] + return Array.from(new Set(store.data.usage.map((row) => row.model))).sort() + }) + + const getDates = createMemo(() => { + const daysInMonth = new Date(store.year, store.month + 1, 0).getDate() + return Array.from({ length: daysInMonth }, (_, i) => { + const date = new Date(store.year, store.month, i + 1) + return date.toISOString().split("T")[0] + }) + }) + + const getKeyName = (keyID: string | null): string => { + if (!keyID || !store.data?.keys) return i18n.t("workspace.cost.allKeys") + const found = store.data.keys.find((k) => k.id === keyID) + if (!found) return i18n.t("workspace.cost.allKeys") + return found.deleted ? `${found.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : found.displayName + } + + const formatMonthYear = () => + new Date(store.year, store.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" }) + + const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth() + + const chartConfig = createMemo((): ChartConfiguration | null => { + const data = store.data + const dates = getDates() + if (!data?.usage?.length) return null + + store.colorScheme + const styles = getComputedStyle(document.documentElement) + const colorTextMuted = styles.getPropertyValue("--color-text-muted").trim() + const colorBorderMuted = styles.getPropertyValue("--color-border-muted").trim() + const colorBgElevated = styles.getPropertyValue("--color-bg-elevated").trim() + const colorText = styles.getPropertyValue("--color-text").trim() + const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim() + const colorBorder = styles.getPropertyValue("--color-border").trim() + const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})` + const liteSuffix = " (go)" + + const dailyDataRegular = new Map<string, Map<string, number>>() + const dailyDataSub = new Map<string, Map<string, number>>() + const dailyDataLite = new Map<string, Map<string, number>>() + for (const dateKey of dates) { + dailyDataRegular.set(dateKey, new Map()) + dailyDataSub.set(dateKey, new Map()) + dailyDataLite.set(dateKey, new Map()) + } + + data.usage + .filter((row) => (store.key ? row.keyId === store.key : true)) + .forEach((row) => { + const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular + const dayMap = targetMap.get(row.date) + if (!dayMap) return + dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost) + }) + + const filteredModels = store.model === null ? getModels() : [store.model] + + // Create datasets: regular first, then subscription, then lite (with visual distinction via opacity) + const datasets = [ + ...filteredModels + .filter((model) => dates.some((date) => (dailyDataRegular.get(date)?.get(model) || 0) > 0)) + .map((model) => { + const color = getModelColor(model) + return { + label: model, + data: dates.map((date) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000), + backgroundColor: color, + hoverBackgroundColor: color, + borderWidth: 0, + stack: "usage", + } + }), + ...filteredModels + .filter((model) => dates.some((date) => (dailyDataSub.get(date)?.get(model) || 0) > 0)) + .map((model) => { + const color = getModelColor(model) + return { + label: `${model}${subSuffix}`, + data: dates.map((date) => (dailyDataSub.get(date)?.get(model) || 0) / 100_000_000), + backgroundColor: addOpacityToColor(color, 0.5), + hoverBackgroundColor: addOpacityToColor(color, 0.7), + borderWidth: 1, + borderColor: color, + stack: "subscription", + } + }), + ...filteredModels + .filter((model) => dates.some((date) => (dailyDataLite.get(date)?.get(model) || 0) > 0)) + .map((model) => { + const color = getModelColor(model) + return { + label: `${model}${liteSuffix}`, + data: dates.map((date) => (dailyDataLite.get(date)?.get(model) || 0) / 100_000_000), + backgroundColor: addOpacityToColor(color, 0.35), + hoverBackgroundColor: addOpacityToColor(color, 0.55), + borderWidth: 1, + borderColor: addOpacityToColor(color, 0.7), + borderDash: [4, 2], + stack: "lite", + } + }), + ] + + return { + type: "bar", + data: { + labels: dates.map(formatDateLabel), + datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + stacked: true, + grid: { + display: false, + }, + ticks: { + maxRotation: 0, + autoSkipPadding: 20, + color: colorTextMuted, + font: { + family: "monospace", + size: 11, + }, + }, + }, + y: { + stacked: true, + beginAtZero: true, + grid: { + color: colorBorderMuted, + }, + ticks: { + color: colorTextMuted, + font: { + family: "monospace", + size: 11, + }, + callback: (value) => { + const num = Number(value) + return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}` + }, + }, + }, + }, + plugins: { + tooltip: { + mode: "index", + intersect: false, + backgroundColor: colorBgElevated, + titleColor: colorText, + bodyColor: colorTextSecondary, + borderColor: colorBorder, + borderWidth: 1, + padding: 12, + displayColors: true, + filter: (item) => (item.parsed.y ?? 0) > 0, + callbacks: { + label: (context) => `${context.dataset.label}: $${(context.parsed.y ?? 0).toFixed(2)}`, + }, + }, + legend: { + display: true, + position: "bottom", + labels: { + color: colorTextSecondary, + font: { + size: 12, + }, + padding: 16, + boxWidth: 16, + boxHeight: 16, + usePointStyle: false, + }, + onHover: (event, legendItem, legend) => { + const chart = legend.chart + chart.data.datasets?.forEach((dataset, i) => { + const meta = chart.getDatasetMeta(i) + const label = dataset.label || "" + const isSub = label.endsWith(subSuffix) + const isLite = label.endsWith(liteSuffix) + const model = isSub + ? label.slice(0, -subSuffix.length) + : isLite + ? label.slice(0, -liteSuffix.length) + : label + const baseColor = getModelColor(model) + const originalColor = isSub + ? addOpacityToColor(baseColor, 0.5) + : isLite + ? addOpacityToColor(baseColor, 0.35) + : baseColor + const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15) + meta.data.forEach((bar: any) => { + bar.options.backgroundColor = color + }) + }) + chart.update("none") + }, + onLeave: (event, legendItem, legend) => { + const chart = legend.chart + chart.data.datasets?.forEach((dataset, i) => { + const meta = chart.getDatasetMeta(i) + const label = dataset.label || "" + const isSub = label.endsWith(subSuffix) + const isLite = label.endsWith(liteSuffix) + const model = isSub + ? label.slice(0, -subSuffix.length) + : isLite + ? label.slice(0, -liteSuffix.length) + : label + const baseColor = getModelColor(model) + const color = isSub + ? addOpacityToColor(baseColor, 0.5) + : isLite + ? addOpacityToColor(baseColor, 0.35) + : baseColor + meta.data.forEach((bar: any) => { + bar.options.backgroundColor = color + }) + }) + chart.update("none") + }, + }, + }, + }, + } + }) + + createEffect(async () => { + const data = await getCosts(params.id!, store.year, store.month) + setStore({ data }) + }) + + createEffect(() => { + const config = chartConfig() + if (!config || !canvasRef) return + + if (chartInstance) chartInstance.destroy() + chartInstance = new Chart(canvasRef, config) + + onCleanup(() => chartInstance?.destroy()) + }) + + createEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + setStore({ colorScheme: mediaQuery.matches ? "dark" : "light" }) + + const handleColorSchemeChange = (e: MediaQueryListEvent) => { + setStore({ colorScheme: e.matches ? "dark" : "light" }) + } + + mediaQuery.addEventListener("change", handleColorSchemeChange) + onCleanup(() => mediaQuery.removeEventListener("change", handleColorSchemeChange)) + }) + + return ( + <section class={styles.root}> + <div data-slot="section-title"> + <h2>{i18n.t("workspace.cost.title")}</h2> + <p>{i18n.t("workspace.cost.subtitle")}</p> + </div> + + <div data-slot="filter-container"> + <div data-slot="month-picker"> + <button data-slot="month-button" onClick={onPreviousMonth}> + <IconChevronLeft /> + </button> + <span data-slot="month-label">{formatMonthYear()}</span> + <button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}> + <IconChevronRight /> + </button> + </div> + <Dropdown + trigger={store.model === null ? i18n.t("workspace.cost.allModels") : store.model} + open={store.modelDropdownOpen} + onOpenChange={(open) => setStore({ modelDropdownOpen: open })} + > + <> + <button data-slot="model-item" onClick={() => onSelectModel(null)}> + <span>{i18n.t("workspace.cost.allModels")}</span> + </button> + <For each={getModels()}> + {(model) => ( + <button data-slot="model-item" onClick={() => onSelectModel(model)}> + <span>{model}</span> + </button> + )} + </For> + </> + </Dropdown> + <Dropdown + trigger={getKeyName(store.key)} + open={store.keyDropdownOpen} + onOpenChange={(open) => setStore({ keyDropdownOpen: open })} + > + <> + <button data-slot="model-item" onClick={() => onSelectKey(null)}> + <span>{i18n.t("workspace.cost.allKeys")}</span> + </button> + <For each={store.data?.keys || []}> + {(key) => ( + <button data-slot="model-item" onClick={() => onSelectKey(key.id)}> + <span> + {key.deleted ? `${key.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : key.displayName} + </span> + </button> + )} + </For> + </> + </Dropdown> + </div> + + <Show + when={chartConfig()} + fallback={ + <div data-component="empty-state"> + <p>{i18n.t("workspace.cost.empty")}</p> + </div> + } + > + <div data-slot="chart-container"> + <canvas ref={canvasRef} /> + </div> + </Show> + </section> + ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/usage/index.tsx b/packages/console/app/src/routes/workspace/[id]/usage/index.tsx new file mode 100644 index 000000000..3a9c8db29 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/index.tsx @@ -0,0 +1,21 @@ +import { Show } from "solid-js" +import { createAsync, useParams } from "@solidjs/router" +import { GraphSection } from "./graph-section" +import { UsageSection } from "./usage-section" +import { querySessionInfo } from "../../common" + +export default function () { + const params = useParams() + const user = createAsync(() => querySessionInfo(params.id!)) + + return ( + <div data-page="workspace-[id]"> + <div data-slot="sections"> + <Show when={user()?.isAdmin}> + <GraphSection /> + </Show> + <UsageSection /> + </div> + </div> + ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css new file mode 100644 index 000000000..00232de88 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css @@ -0,0 +1,185 @@ +.root { + /* Empty state */ + [data-component="empty-state"] { + padding: var(--space-20) var(--space-6); + text-align: center; + border: 1px dashed var(--color-border); + border-radius: var(--border-radius-sm); + + p { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + } + + /* Table container */ + [data-slot="usage-table"] { + overflow-x: auto; + } + + /* Table element */ + [data-slot="usage-table-element"] { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + thead { + border-bottom: 1px solid var(--color-border); + } + + th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: normal; + color: var(--color-text-muted); + text-transform: uppercase; + } + + td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-muted); + color: var(--color-text-muted); + font-family: var(--font-mono); + + &[data-slot="usage-date"] { + color: var(--color-text-muted); + } + + &[data-slot="usage-model"] { + font-family: var(--font-sans); + color: var(--color-text-secondary); + max-width: 200px; + word-break: break-word; + } + + &[data-slot="usage-cost"] { + color: var(--color-text-muted); + } + + [data-slot="tokens-with-breakdown"] { + position: relative; + display: flex; + align-items: center; + gap: var(--space-2); + } + + [data-slot="breakdown-button"] { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + border: none; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + color: var(--color-text); + } + + svg { + width: 16px; + height: 16px; + } + } + + [data-slot="breakdown-popup"] { + position: absolute; + left: 0; + top: 100%; + margin-top: var(--space-2); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + z-index: 10; + min-width: 180px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + font-size: var(--font-size-xs); + + @media (prefers-color-scheme: dark) { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + } + } + + tbody tr:last-child td { + border-bottom: none; + } + } + + /* Pagination */ + [data-slot="pagination"] { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-4) 0; + border-top: 1px solid var(--color-border-muted); + margin-top: var(--space-2); + + button { + padding: var(--space-2) var(--space-4); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + color: var(--color-text); + font-size: var(--font-size-sm); + cursor: pointer; + transition: all 0.15s ease; + + svg { + width: 16px; + height: 16px; + stroke-width: 2; + } + + &:hover:not(:disabled) { + background: var(--color-bg-tertiary); + border-color: var(--color-border-hover); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + + /* Mobile responsive */ + @media (max-width: 40rem) { + [data-slot="usage-table-element"] { + th, + td { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + } + + /* Hide Model column on mobile */ + th:nth-child(2), + td:nth-child(2) { + display: none; + } + } + } + + /* Breakdown popup content */ + [data-slot="breakdown-row"] { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + padding: var(--space-1) 0; + } + + [data-slot="breakdown-label"] { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + } + + [data-slot="breakdown-value"] { + color: var(--color-text); + font-weight: 500; + font-size: var(--font-size-xs); + } +} diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx new file mode 100644 index 000000000..2cf8ef850 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx @@ -0,0 +1,217 @@ +import { Billing } from "@opencode-ai/console-core/billing.js" +import { createAsync, query, useParams } from "@solidjs/router" +import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js" +import { formatDateUTC, formatDateForTable } from "../../common" +import { withActor } from "~/context/auth.withActor" +import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon" +import styles from "./usage-section.module.css" +import { createStore } from "solid-js/store" +import { useI18n } from "~/context/i18n" + +const PAGE_SIZE = 50 + +async function getUsageInfo(workspaceID: string, page: number) { + "use server" + return withActor(async () => { + return await Billing.usages(page, PAGE_SIZE) + }, workspaceID) +} + +const queryUsageInfo = query(getUsageInfo, "usage.list") + +export function UsageSection() { + const params = useParams() + const i18n = useI18n() + const usage = createAsync(() => queryUsageInfo(params.id!, 0)) + const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> }) + const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null) + + createEffect(() => { + setStore({ usage: usage() }) + }, [usage]) + + createEffect(() => { + if (!openBreakdownId()) return + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + if (!target.closest('[data-slot="tokens-with-breakdown"]')) { + setOpenBreakdownId(null) + } + } + + document.addEventListener("click", handleClickOutside) + return () => document.removeEventListener("click", handleClickOutside) + }) + + const hasResults = createMemo(() => store.usage && store.usage.length > 0) + const canGoPrev = createMemo(() => store.page > 0) + const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE) + + const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => { + return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0) + } + + const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => { + return u.outputTokens + (u.reasoningTokens ?? 0) + } + + const goPrev = async () => { + const usage = await getUsageInfo(params.id!, store.page - 1) + setStore({ + page: store.page - 1, + usage, + }) + } + const goNext = async () => { + const usage = await getUsageInfo(params.id!, store.page + 1) + setStore({ + page: store.page + 1, + usage, + }) + } + + return ( + <section class={styles.root}> + <div data-slot="section-title"> + <h2>{i18n.t("workspace.usage.title")}</h2> + <p>{i18n.t("workspace.usage.subtitle")}</p> + </div> + <div data-slot="usage-table"> + <Show + when={hasResults()} + fallback={ + <div data-component="empty-state"> + <p>{i18n.t("workspace.usage.empty")}</p> + </div> + } + > + <table data-slot="usage-table-element"> + <thead> + <tr> + <th>{i18n.t("workspace.usage.table.date")}</th> + <th>{i18n.t("workspace.usage.table.model")}</th> + <th>{i18n.t("workspace.usage.table.input")}</th> + <th>{i18n.t("workspace.usage.table.output")}</th> + <th>{i18n.t("workspace.usage.table.cost")}</th> + <th>{i18n.t("workspace.usage.table.session")}</th> + </tr> + </thead> + <tbody> + <For each={store.usage}> + {(usage, index) => { + const date = createMemo(() => new Date(usage.timeCreated)) + const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage)) + const totalOutputTokens = createMemo(() => calculateTotalOutputTokens(usage)) + const inputBreakdownId = `input-breakdown-${index()}` + const outputBreakdownId = `output-breakdown-${index()}` + const isInputOpen = createMemo(() => openBreakdownId() === inputBreakdownId) + const isOutputOpen = createMemo(() => openBreakdownId() === outputBreakdownId) + const isClaude = usage.model.toLowerCase().includes("claude") + return ( + <tr> + <td data-slot="usage-date" title={formatDateUTC(date())}> + {formatDateForTable(date())} + </td> + <td data-slot="usage-model">{usage.model}</td> + <td data-slot="usage-tokens"> + <div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}> + <button + data-slot="breakdown-button" + onClick={(e) => { + e.stopPropagation() + setOpenBreakdownId(isInputOpen() ? null : inputBreakdownId) + }} + > + <IconBreakdown /> + </button> + <span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span> + <Show when={isInputOpen()}> + <div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}> + <div data-slot="breakdown-row"> + <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.input")}</span> + <span data-slot="breakdown-value">{usage.inputTokens}</span> + </div> + <div data-slot="breakdown-row"> + <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.cacheRead")}</span> + <span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span> + </div> + <Show when={isClaude}> + <div data-slot="breakdown-row"> + <span data-slot="breakdown-label"> + {i18n.t("workspace.usage.breakdown.cacheWrite")} + </span> + <span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span> + </div> + </Show> + </div> + </Show> + </div> + </td> + <td data-slot="usage-tokens"> + <div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}> + <button + data-slot="breakdown-button" + onClick={(e) => { + e.stopPropagation() + setOpenBreakdownId(isOutputOpen() ? null : outputBreakdownId) + }} + > + <IconBreakdown /> + </button> + <span onClick={() => setOpenBreakdownId(null)}>{totalOutputTokens()}</span> + <Show when={isOutputOpen()}> + <div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}> + <div data-slot="breakdown-row"> + <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.output")}</span> + <span data-slot="breakdown-value">{usage.outputTokens}</span> + </div> + <div data-slot="breakdown-row"> + <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.reasoning")}</span> + <span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span> + </div> + </div> + </Show> + </div> + </td> + <td data-slot="usage-cost"> + <Switch fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}> + <Match when={usage.enrichment?.plan === "sub"}> + {i18n.t("workspace.usage.subscription", { + amount: ((usage.cost ?? 0) / 100000000).toFixed(4), + })} + </Match> + <Match when={usage.enrichment?.plan === "lite"}> + {i18n.t("workspace.usage.lite", { + amount: ((usage.cost ?? 0) / 100000000).toFixed(4), + })} + </Match> + <Match when={usage.enrichment?.plan === "byok"}> + {i18n.t("workspace.usage.byok", { + amount: ((usage.cost ?? 0) / 100000000).toFixed(4), + })} + </Match> + </Switch> + </td> + <td data-slot="usage-session">{usage.sessionID?.slice(-8) ?? "-"}</td> + </tr> + ) + }} + </For> + </tbody> + </table> + <Show when={canGoPrev() || canGoNext()}> + <div data-slot="pagination"> + <button disabled={!canGoPrev()} onClick={goPrev}> + <IconChevronLeft /> + </button> + <button disabled={!canGoNext()} onClick={goNext}> + <IconChevronRight /> + </button> + </div> + </Show> + </Show> + </div> + </section> + ) +} |
